ChatGPT時代に必要かも!? Pythonで実行するファイルパース(PDF編)
こんちには。
データアナリティクス事業本部 インテグレーション部 機械学習チームの中村です。
今回は話題のChatGPTにコンテキストを与える際に必要となるファイルパース処理について見ていきたいと思います。
本記事ではPDFに焦点を絞ってみていきます。既存のライブラリ内の実装も確認していきます。
先行事例の実装
先行事例の実装として、よく話題となる以下のライブラリを見ていきます。
(LlamaIndexとLlamaHubはほぼ同じですが、parserとしては片方にしかないものもあるため)
- LlamaIndex
- LlamaHub
- LangChain
- chat-gpt-retrieval-plugin
LlamaIndex
LlamaIndexの場合、docs_parser.pyにPDFParserというクラスで実装されています。
依存しているライブラリはPyPDF2です。
コードの抜粋は以下となります。
text_list = [] with open(file, "rb") as fp: # Create a PDF object pdf = PyPDF2.PdfReader(fp) # Get the number of pages in the PDF document num_pages = len(pdf.pages) # Iterate over every page for page in range(num_pages): # Extract the text from the page page_text = pdf.pages[page].extract_text() text_list.append(page_text) text = "\n".join(text_list)
LlamaHub
LlamaHubには、複数のローダーの実装があるようです。
- PDFReader
- 基本のPDFパーサー
- CJKPDFReader
- 中国語・日本語・韓国語に対応したパーサー
- FlatPdfReader
- PDFのそれぞれのページを画像と見なしてパースするパーサー(日本語対応は厳しい)
この中で日本語のことを考えた場合は、CJKPDFReaderが良い選択肢となりそうな印象です。以降、詳細を説明します。
PDFReader
こちらが基本のPDFパーサーになります。コードは以下になります。
確認したところ、LlamaIndexと同じ実装となっているようです。(依存ライブラリも同じくPyPDF2)
CJKPDFReader
こちらは、中国語・日本語・韓国語に対応したパーサーとなっているようです。コードは以下となります。
依存ライブラリも異なっており、こちらはpdfminer.sixが使用されます。
コードの抜粋は以下となります。
def _extract_text_by_page(self, pdf_path: Path) -> List[str]: # Import pdfminer from io import StringIO from pdfminer.converter import TextConverter from pdfminer.layout import LAParams from pdfminer.pdfinterp import PDFPageInterpreter, PDFResourceManager from pdfminer.pdfpage import PDFPage # Create a resource manager rsrcmgr = PDFResourceManager() # Create an object to store the text retstr = StringIO() # Create a text converter codec = "utf-8" laparams = LAParams() device = TextConverter(rsrcmgr, retstr, codec=codec, laparams=laparams) # Create a PDF interpreter interpreter = PDFPageInterpreter(rsrcmgr, device) # Open the PDF file fp = open(pdf_path, "rb") # Create a list to store the text of each page text_list = [] # Extract text from each page for page in PDFPage.get_pages(fp): interpreter.process_page(page) # Get the text text = retstr.getvalue() # Add the text to the list text_list.append(text) # Clear the text retstr.truncate(0) retstr.seek(0) # Close the file fp.close() # Close the device device.close() # Return the text list return text_list
PyPDF2と比較すると少し扱いが煩雑そうですが、日本語データに対してはより適切な出力を得られる可能性があります。
FlatPdfReader
こちらは、PDFのそれぞれのページを画像と見なしてパースするパーサーとなっているようです。コードは以下となります。
依存するライブラリはPyMuPDF(import fitz)となっていますが、こちらはPDFを画像として抽出するために使用され、実際のテキスト抽出は、ImageReaderが担っています。
ImageReaderのコードは以下です。
ImageReaderの中身としては、donut-base-finetuned-cord-v2をHugging Faceから取得して使用しています。
こちらは、以前のLlamaIndexの以下の記事のときに少し調べました。
DonutモデルはOCRフリーであることを目指したVDU(Visual Document Understanding)モデルとなっており、その出力をJSONのようなkey-valueの形で得ることができます。
英語のCORDというデータセットでfine-tuningされているため、日本語対応していない点も注意が必要でした。
LangChain
LangChainの場合は以下に実装されています。
BasePDFLoaderのサブクラスとして、以下のように様々な実装が準備されています。
- OnlinePDFLoader
- unstructuredというライブラリを使用するパーサー
- 内部的には3種類の処理があり、unstructuredのAPIで処理とローカルで実行する処理に分かれており、ローカルでの処理は、テキスト抽出が可能な場合はpdfminer.sixで処理され、テキスト抽出が可能でない場合、detectron2で物体検出後、tesseractでOCRによる処理が行われる
- PyPDFLoader
- pypdfというライブラリを使用するパーサー
- PDFMinerLoader
- pdfminer.sixというライブラリを使用するパーサー
- PDFMinerPDFasHTMLLoader
- pdfminer.sixというライブラリを使用するパーサーだが、PDFをHTMLコンテンツとして読み込むような形でパースする
- PyMuPDFLoader
- PyMuPDFというライブラリを使用するパーサー
- MathpixPDFLoader
- MathpixのAPIを使用するパーサー
OnlinePDFLoader
OnlinePDFLoaderは、unstructuredというライブラリで処理をするパーサーとなっています。
unstructuredでは、まず以下の部分でPDFからテキスト抽出が可能かを確認し、可能な場合はテキストとして処理され、可能でない場合は画像として処理されます。
テキストとして処理する場合
この場合はpdfminer.sixを用いて処理されています。コードは以下が該当します。
コードの抜粋は以下となります。
from pdfminer.converter import TextConverter from pdfminer.layout import LAParams from pdfminer.pdfinterp import PDFPageInterpreter, PDFResourceManager rsrcmgr = PDFResourceManager(caching=False) laparams = LAParams() elements: List[Element] = [] for i, page in enumerate(PDFPage.get_pages(fp, check_extractable=True)): metadata = ElementMetadata(filename=filename, page_number=i + 1) with StringIO() as output_string: device = TextConverter( rsrcmgr, output_string, codec=encoding, laparams=laparams, ) interpreter = PDFPageInterpreter(rsrcmgr, device) interpreter.process_page(page) text = output_string.getvalue() _elements = partition_text(text=text) for element in _elements: element.metadata = metadata elements.append(element) if include_page_breaks: elements.append(PageBreak())
画像として処理する場合
画像として処理する場合は依存関係が少し複雑になっていて追うのが大変でした。
- unstructured
- unstructured-inference
- layout-parser
- detectron2
unstructuredからunstructured-inferenceを呼び出しているのは以下です。
unstructured-inferenceの推論部分は以下です。
もう少し細かく見ると以下にあるように2ステージで処理されることが分かります。
- レイアウト種類の判別と物体検出
- そのレイアウト内のテキストをOCRで抽出
レイアウト種類の判別と物体検出は、layout-parser経由でdetectron2を使っています。
以下にdetectron2をロードするコードがあります。
detectron2の詳細は以下に記述されており、このモデルが判別可能なレイアウト種別などが確認できます。
重みファイルなどから、Faster-RCNNが使用されていることが分かります。
また、layoutparserライブラリ側にdetectron2自体の定義はありそうです。
詳細は、以下のlayoutparser側のコードを参照ください。
OCRのコードは以下で、tesseractというライブラリを使っています。
tesseractは日本語に対応しているため、日本語に対する処理も可能と考えられます。
PyPDFLoader
PyPDFLoaderは、pypdfというライブラリを使用するパーサーとなっています。コードは以下となります。
コードの抜粋は以下となります。
def load(self) -> List[Document]: """Load given path as pages.""" import pypdf with open(self.file_path, "rb") as pdf_file_obj: pdf_reader = pypdf.PdfReader(pdf_file_obj) return [ Document( page_content=page.extract_text(), metadata={"source": self.file_path, "page": i}, ) for i, page in enumerate(pdf_reader.pages) ]
PDFMinerLoader
PDFMinerLoaderは、pdfminer.sixというライブラリを使用するパーサーとなっています。コードは以下となります。
コードの抜粋は以下となります。
def load(self) -> List[Document]: """Load file.""" from pdfminer.high_level import extract_text text = extract_text(self.file_path) metadata = {"source": self.file_path} return [Document(page_content=text, metadata=metadata)]
LlamaHubと異なり、high_levelなAPIを使っているようです。
PDFMinerPDFasHTMLLoader
PDFMinerPDFasHTMLLoaderも、pdfminer.sixというライブラリを使用するパーサーとなっています。コードは以下となります。
コードの抜粋は以下となります。
def load(self) -> List[Document]: """Load file.""" from pdfminer.high_level import extract_text_to_fp from pdfminer.layout import LAParams from pdfminer.utils import open_filename output_string = StringIO() with open_filename(self.file_path, "rb") as fp: extract_text_to_fp( fp, # type: ignore[arg-type] output_string, codec="", laparams=LAParams(), output_type="html", ) metadata = {"source": self.file_path} return [Document(page_content=output_string.getvalue(), metadata=metadata)]
high_levelなAPIを使う点は同じですが、少し別のAPIを使用しており、PDFファイルをHTMLコンテンツとして読み込むような形となっているようです。
PyMuPDFLoader
PyMuPDFLoaderはPyMuPDFというライブラリを使用するパーサーとなっています。コードは以下となります。
コードの抜粋は以下となります。
def load(self, **kwargs: Optional[Any]) -> List[Document]: """Load file.""" import fitz doc = fitz.open(self.file_path) # open document file_path = self.file_path if self.web_path is None else self.web_path return [ Document( page_content=page.get_text(**kwargs).encode("utf-8"), metadata=dict( { "source": file_path, "file_path": file_path, "page_number": page.number + 1, "total_pages": len(doc), }, **{ k: doc.metadata[k] for k in doc.metadata if type(doc.metadata[k]) in [str, int] }, ), ) for page in doc ]
MathpixPDFLoader
MathpixPDFLoaderは、MathpixのAPIを使用するパーサーとなっています。コードとしては以下となります。
コードの抜粋は以下となります。
def load(self) -> List[Document]: pdf_id = self.send_pdf() contents = self.get_processed_pdf(pdf_id) if self.should_clean_pdf: contents = self.clean_pdf(contents) metadata = {"source": self.source, "file_path": self.source} return [Document(page_content=contents, metadata=metadata)]
APIのエンドポイントにアクセスしており、使用するにはAPI KEYなどが必要となりそうです。
またこのエンドポイントへのアクセスのコードは以下の Daniel Gross のgistを参考にしているようです。
chat-gpt-retrieval-plugin
以下にその実装があります。
依存しているライブラリはPyPDF2です。
コードの抜粋は以下となります。
reader = PdfReader(file) extracted_text = " ".join([page.extract_text() for page in reader.pages])
既存ライブラリの実装のまとめ
数が多いので以下のように整理しました。
パターン | 依存するライブラリ |
---|---|
LlamaIndex | PyPDF2 |
LlamaHub (PDFReader) | PyPDF2 |
LlamaHub (CJKPDFReader) | pdfminer.six |
LlamaHub (FlatPdfReader) | Hugging Face Transformers経由のDonutモデル |
LangChain (OnlinePDFLoader) | unstructuredのAPI pdfminer.six detectron2(Faster-RCNN) + tesseract |
LangChain (PyPDFLoader) | pypdf |
LangChain (PDFMinerLoader) | pdfminer.six |
LangChain (PDFMinerPDFasHTMLLoader) | pdfminer.six |
LangChain (PyMuPDFLoader) | PyMuPDF |
LangChain (MathpixPDFLoader) | MathpixのAPI |
種別にすると以下のようになります。
- API経由
- unstructured API, Mathpix API
- 画像に対するアプローチ
- Donutsモデル, detectron2(Faster-RCNN) + tesseract
- テキスト抽出
- PyPDF2, pdfminer.six, pypdf, PyMuPDF
どれもページ単位で処理を行うため、ページ番号をmetadataとして保持しておけば、どのページを根拠にChatGPTが回答したのか、などの実装も可能になると思います。
ここからはテキスト抽出に着目して、pypdf, pdfminer.six, PyMuPDFそれぞれを試していきたいと思います。
なお、PyPDF2は3.0.xで開発が止まっており、3.1.0以降がpypdfに引き継がれ、使用方法も変わらないため、今回は省きました。この話についての詳細は、下記も参照ください。
試してみた
サンプルデータの準備
まずサンプルのPDFファイルをWordを使って作成します。以下のような構成のページのものにしました。
均等割り付けや段組構成、表などよく登場しそうな形式を含むようにしています。
データはsample.pdf
としてワークディレクトリ直下に配置しておきます。
実行環境
Google Colaboratoryを使います。特にスペックは求められないので、どのバージョンでも動作すると思います。
Pythonのバージョンは以下でした。
!python --version
Python 3.10.11
以下でライブラリをそれぞれ入れていきます。
!pip install PyPDF2 !pip install pypdf !pip install PyMuPDF !pip install pdfminer.six
インストール後のバージョンは以下となりました。
!pip freeze | grep -e "PyPDF2" -e "pypdf" -e "PyMuPDF" -e "pdfminer.six"
pdfminer.six==20221105 PyMuPDF==1.22.2 pypdf==3.8.1 PyPDF2==3.0.1
pypdfの場合
以下がパースするコードになります。
import pypdf reader = pypdf.PdfReader("sample.pdf") extracted_text = "\n".join([page.extract_text() for page in reader.pages]) with open("result_pypdf.txt", "wt") as fp: fp.writelines(extracted_text)
結果は以下のようになりました。時折空白が入ってきますが、均等割り付けや段組構成、表などなど意図通りに抽出できています。
何 か の 報 告 書 はい、みなさんこんばんは。 クラスメソッ ドがですね、ラスベガスからリインベント の様子をお届けします。 Developers IEO in Las Vegas 2022 ということで始めさせてい ただきます。 どうぞよろしくお願いします。 日本は今ですね、 12月1日のお昼 12時の 時間帯だと思いますけれども、 こちらはで すね、まだ 11月30日の夜 7時ということ で、 ちょっと夜でですね、お疲れもあって ちょっと変な感じになるんですけれども、 暖かく聞いていただければと思います。 は い、ちなみに今何人くらい入ってらっしゃ るんですか ?視聴者の方は。 104名です。 素晴らしいですね。 ありがとうございます。 この配信ではですね、 AWS Re -Invent 2022 でですね、たくさんのアップデートだった りとかですね、 セッション、キーノート等 ありましたので、ここまでで出ている情報 をもとにですね、 クラスメソッドのエンジ ニアが注目した内容とか、 そういったもの をですね、エンジニアメンバーから聞いて いきたいと思います。 今回ですね、人数の 都合上ですね、 二部制となっておりまして、 今回今出ているのが前半のメンバーという ことで、 大体30分くらい目途でですね 、 後半のメンバーにチェンジしてお送りして いきたいと思います。 じゃあまずですね、 今出ている前半メンバーの自己紹介をした いと思いますので、 順によろしくお願いし ます。 はい、 ARXジオン本部のコンサルテ ィング部で働いてます。 門別と申します。 WebIOとか Twitterとかは MOKOという 名前でやってます。 よろしくお願いします。 イェーイ。 ク ラ ス メ ソ ッ ド の PRISMATICS 事業部というところでエン ジニアをしています。 エラオーカーと申し ます。 インターネットではトバシという名 前で存在しています。よろしく お願いしま す。 おぉー。 クラスメソッドの CXジオ 本部というところにいます。 主にサーバー サイドエンジニアとかバックエンドのエン ジニアをやっています。 あと普段の活動と しては JawsUG のCDK支部みたいなとこ ろで運営とかをやったりしています。 今回 CDKのアップデートとか気になってきて いたりします。よろしくお願いします。 カラム1 カラム2 カラム3 サンプル A 100 90 ○ サンプル B 80 30 △ サンプル C 20 0 × サンプル D 10 10 △
なお、旧版のPyPDF2も同じパース結果となりました。
pdfminer.sixの場合
pdfminer.sixはいくつかの種類のAPIがあるのですが、今回はもっともシンプルそうな高水準API方法を選択して試してみました。以下がコードになります。
from pdfminer.high_level import extract_text extracted_text = extract_text("sample.pdf") with open("result_pdfminer.six.txt", "wt") as fp: fp.writelines(extracted_text)
結果は以下のようになりました。全体的に少し意図とは異なるパース結果となっています。
何 か の 報 告 書 はい、みなさんこんばんは。 クラスメソッ 今回今出ているのが前半のメンバーという ドがですね、ラスベガスからリインベント ことで、 大体 30 分くらい目途でですね、 の様子をお届けします。 Developers IEO in 後半のメンバーにチェンジしてお送りして Las Vegas 2022 ということで始めさせてい いきたいと思います。 じゃあまずですね、 ただきます。 どうぞよろしくお願いします。 今出ている前半メンバーの自己紹介をした 日本は今ですね、12 月 1 日のお昼 12 時の いと思いますので、 順によろしくお願いし 時間帯だと思いますけれども、 こちらはで ます。 はい、ARX ジオン本部のコンサルテ すね、まだ 11 月 30 日の夜 7 時ということ ィング部で働いてます。 門別と申します。 で、 ちょっと夜でですね、お疲れもあって WebIO とか Twitter とかは MOKO という ちょっと変な感じになるんですけれども、 名前でやってます。よろしくお願いします。 暖かく聞いていただければと思います。 は イ ェ ー イ 。 ク ラ ス メ ソ ッ ド の い、ちなみに今何人くらい入ってらっしゃ PRISMATICS 事業部というところでエン るんですか?視聴者の方は。 104 名です。 ジニアをしています。 エラオーカーと申し 素晴らしいですね。 ありがとうございます。 ます。 インターネットではトバシという名 この配信ではですね、 AWS Re-Invent 2022 前で存在しています。よろしくお願いしま でですね、たくさんのアップデートだった す。 おぉー。 クラスメソッドの CX ジオ りとかですね、 セッション、キーノート等 本部というところにいます。 主にサーバー ありましたので、ここまでで出ている情報 サイドエンジニアとかバックエンドのエン をもとにですね、 クラスメソッドのエンジ ジニアをやっています。 あと普段の活動と ニアが注目した内容とか、 そういったもの しては JawsUG の CDK 支部みたいなとこ をですね、エンジニアメンバーから聞いて ろで運営とかをやったりしています。 今回 いきたいと思います。 今回ですね、人数の CDK のアップデートとか気になってきて 都合上ですね、二部制となっておりまして、 いたりします。よろしくお願いします。 サンプル A サンプル B サンプル C サンプル D カラム1 カラム2 カラム3 100 80 20 10 90 30 0 10 ○ △ × △
改行が増えている点は、後処理でどうにかできそうですが、段組のパース順序が異なったりもするため、ここは注意が必要そうです。
低水準なAPIも試してみましたが、結果はあまり変わりませんでした。
PyMuPDFの場合
以下がパースするコードになります。
import fitz # this is PyMuPDF extracted_text = "\n".join([page.get_text() for page in fitz.open("sample.pdf")]) with open("result_PyMuPDF.txt", "wt") as fp: fp.writelines(extracted_text)
結果は以下のようになりました。段組構成は意図通りにパースされていますが、均等割り付けや表は少し意図とは異なるパース結果となっています。
何 か の 報 告 書 はい、みなさんこんばんは。 クラスメソッ ドがですね、ラスベガスからリインベント の様子をお届けします。 Developers IEO in Las Vegas 2022 ということで始めさせてい ただきます。 どうぞよろしくお願いします。 日本は今ですね、12 月 1 日のお昼 12 時の 時間帯だと思いますけれども、 こちらはで すね、まだ 11 月 30 日の夜 7 時ということ で、 ちょっと夜でですね、お疲れもあって ちょっと変な感じになるんですけれども、 暖かく聞いていただければと思います。 は い、ちなみに今何人くらい入ってらっしゃ るんですか?視聴者の方は。 104 名です。 素晴らしいですね。 ありがとうございます。 この配信ではですね、 AWS Re-Invent 2022 でですね、たくさんのアップデートだった りとかですね、 セッション、キーノート等 ありましたので、ここまでで出ている情報 をもとにですね、 クラスメソッドのエンジ ニアが注目した内容とか、 そういったもの をですね、エンジニアメンバーから聞いて いきたいと思います。 今回ですね、人数の 都合上ですね、二部制となっておりまして、 今回今出ているのが前半のメンバーという ことで、 大体 30 分くらい目途でですね、 後半のメンバーにチェンジしてお送りして いきたいと思います。 じゃあまずですね、 今出ている前半メンバーの自己紹介をした いと思いますので、 順によろしくお願いし ます。 はい、ARX ジオン本部のコンサルテ ィング部で働いてます。 門別と申します。 WebIO とか Twitter とかは MOKO という 名前でやってます。よろしくお願いします。 イ ェ ー イ 。 ク ラ ス メ ソ ッ ド の PRISMATICS 事業部というところでエン ジニアをしています。 エラオーカーと申し ます。 インターネットではトバシという名 前で存在しています。よろしくお願いしま す。 おぉー。 クラスメソッドの CX ジオ 本部というところにいます。 主にサーバー サイドエンジニアとかバックエンドのエン ジニアをやっています。 あと普段の活動と しては JawsUG の CDK 支部みたいなとこ ろで運営とかをやったりしています。 今回 CDK のアップデートとか気になってきて いたりします。よろしくお願いします。 カラム1 カラム2 カラム3 サンプル A 100 90 ○ サンプル B 80 30 △ サンプル C 20 0 × サンプル D 10 10 △
まとめ
いかがでしたでしょうか。1つのサンプルデータで見る挙動の範囲内では、pypdfがもっとも意図に近いパースを行ってくれそうです。
これ以外の以下については今回は動かしてみることができませんでしたので、今後余力があれば試したいと思います。
- API経由
- unstructured API, Mathpix API
- 画像に対するアプローチ
- Donutsモデル, detectron2(Faster-RCNN) + tesseract
本記事がPDFをパースしようと苦労されている方の参考になれば幸いです。